1. 기본 골격 — 리턴코드 · 가드매크로 · 단일출구 cleanup

한 문장 요약 C 함수는 전부 같은 모양으로 쓴다. 가드로 시작 → 자원 잡기 → 로직 → goto CLEANUP → 잡은 역순으로 놓기 → 리턴. 이 모양을 벗어나는 순간 메모리가 새기 시작한다.


0. 왜 “모양을 통일”하는가

C에는 예외(exception)도, 소멸자(destructor)도, GC도 없다. 그래서 에러가 날 때마다 내가 직접 뒷정리를 해야 한다.

초보가 쓰는 코드는 보통 이렇게 생겼다.

/* ✗ 이렇게 쓰면 반드시 샌다 */
int do_something(const char *name)
{
    char *buf = malloc(1024);
    FILE *fp = fopen(name, "r");
 
    if (fp == NULL) {
        return -1;                  /* ✗ buf 가 샌다 */
    }
    if (fread(buf, 1, 1024, fp) == 0) {
        fclose(fp);
        return -1;                  /* ✗ buf 가 또 샌다 */
    }
    ...
    free(buf);
    fclose(fp);
    return 0;
}

에러 경로가 3개면 뒷정리 코드도 3벌이다. 그중 하나만 빠져도 샌다. 그리고 에러 경로는 시험에서 거의 안 밟힌다. 그래서 운영 나가서 터진다.

/* ✓ 단일 출구: 뒷정리가 딱 한 벌 */
int do_something(const char *name)
{
    int   ret = RET_FAIL;           /* ① 실패를 기본값으로 */
    char *buf = NULL;               /* ② 포인터는 전부 NULL 로 선언 */
    FILE *fp  = NULL;
 
    CHK_PTR(name);                  /* ③ 가드 */
 
    buf = malloc(1024);
    CHK_ALLOC_GOTO(buf, ret, CLEANUP);   /* ④ 실패하면 CLEANUP 으로 (할당 실패는 NO_MEM) */
 
    fp = fopen(name, "r");
    CHK_COND_GOTO(fp != NULL, RET_IO_ERROR, ret, CLEANUP);
 
    if (fread(buf, 1, 1024, fp) == 0) goto CLEANUP;
 
    ...
    ret = RET_OK;                   /* ⑤ 끝까지 왔을 때만 성공 */
 
CLEANUP:                            /* ⑥ 뒷정리는 여기 한 곳뿐 */
    if (fp)  { fclose(fp); fp = NULL; }
    SAFE_FREE(buf);
    return ret;
}

이 여섯 줄이 전부다

① ret 초기값은 실패. ② 포인터는 전부 NULL 초기화. ③ 가드로 인자 검사. ④ 실패하면 goto CLEANUP. ⑤ 성공은 맨 마지막 한 줄. ⑥ 해제는 잡은 역순으로 한 곳에서.

이 골격은 사내 프레임워크 코드에서도 똑같이 쓴다 → 실무 3. 네이밍·에러·로그·자원해제 규약


1. 리턴코드 체계

1-1. 세 가지 방식과 선택 기준

방식모양언제 쓰나단점
0 / -1return 0; / return -1;실패 이유가 하나뿐일 때”왜 실패했는지” 못 알려줌
enum 에러코드return E_INVALID_ARG;실패 이유가 여러 개일 때상수를 관리해야 함
출력 파라미터int f(in, out, int *err)값과 에러를 둘 다 줘야 할 때호출이 번거로움

기본은 enum 으로 시작하라

0 / -1 로 시작하면 나중에 “이 실패가 입력 문제인지 시스템 문제인지” 를 구분해야 할 때 모든 호출부를 고쳐야 한다. 처음부터 enum 으로 두면 값만 추가하면 된다.

1-2. 복붙용 리턴코드 정의

/* ══════════════ common.h — 프로젝트 하나당 한 벌 ══════════════ */
#ifndef COMMON_H
#define COMMON_H
 
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <errno.h>
 
/* ── 리턴코드 ─────────────────────────────────────────
 *  성공은 0 하나. 실패는 전부 음수.
 *  "if (ret != RET_OK)" 한 줄로 모든 실패를 잡을 수 있다.
 * ──────────────────────────────────────────────────── */
typedef enum {
    RET_OK           =  0,    /* 성공 */
    RET_FAIL         = -1,    /* 분류 안 된 일반 실패 */
    RET_INVALID_ARG  = -2,    /* 인자가 잘못됨 (NULL, 범위 밖) */
    RET_NO_MEM       = -3,    /* malloc 실패 */
    RET_NOT_FOUND    = -4,    /* 찾는 대상이 없음 (에러 아닐 수도) */
    RET_TOO_SMALL    = -5,    /* 버퍼가 모자람 */
    RET_BAD_FORMAT   = -6,    /* 파싱 실패, 규격 위반 */
    RET_TIMEOUT      = -7,    /* 시간 초과 */
    RET_IO_ERROR     = -8,    /* read/write/socket 실패 */
    RET_NOT_SUPPORTED= -9,    /* 아직 구현 안 함 / 규격상 미지원 */
} ret_t;
 
/* 에러코드를 사람이 읽는 문자열로. 로그 찍을 때 필수 */
static inline const char *ret_str(int r)
{
    switch (r) {
    case RET_OK:            return "OK";
    case RET_FAIL:          return "FAIL";
    case RET_INVALID_ARG:   return "INVALID_ARG";
    case RET_NO_MEM:        return "NO_MEM";
    case RET_NOT_FOUND:     return "NOT_FOUND";
    case RET_TOO_SMALL:     return "TOO_SMALL";
    case RET_BAD_FORMAT:    return "BAD_FORMAT";
    case RET_TIMEOUT:       return "TIMEOUT";
    case RET_IO_ERROR:      return "IO_ERROR";
    case RET_NOT_SUPPORTED: return "NOT_SUPPORTED";
    default:                return "UNKNOWN";
    }
}
 
#endif /* COMMON_H */

RET_NOT_FOUND 는 에러가 아닐 수 있다

“조회했는데 없다” 는 정상적인 결과인 경우가 많다 (HTTP 404). 그래서 if (ret < 0) 로 뭉뚱그리면 안 되고, if (ret == RET_NOT_FOUND) 를 따로 처리해야 하는 경우가 생긴다. 실제 상용 코드에서도 DB 조회는 실패 / 0건 / 성공 세 갈래로 나눈다.

1-3. 호출부에서 쓰는 모양

int ret = 0;
 
ret = do_something(name);
if (ret != RET_OK) {
    LOG_ERR("do_something(%s) failed: %s(%d)", name, ret_str(ret), ret);
    return ret;                     /* ★ 에러코드를 그대로 위로 전달 */
}

에러코드는 바꾸지 말고 그대로 올린다

중간 함수에서 return RET_FAIL; 로 덮어쓰면, 맨 위에서는 “뭔가 실패했다” 밖에 알 수 없다. 원래 코드를 그대로 전달해야 맨 위 로그만 보고도 “아, 메모리가 모자랐구나” 를 안다.


2. 가드 매크로 — 함수 첫 줄에 놓는 검사

2-1. 복붙용 가드 매크로 전문

/* ══════════════ guard.h ══════════════ */
#ifndef GUARD_H
#define GUARD_H
 
#include "common.h"
#include "log.h"
#include "safe.h"      /* SAFE_* 를 같이 쓰게 한다 */
 
/* ── 즉시 return 하는 가드 (자원을 아직 안 잡았을 때) ── */
 
#define CHK_PTR(_p)                                                     \
    do {                                                                \
        if ((_p) == NULL) {                                             \
            LOG_ERR("%s is NULL (%s/%d)", #_p, __func__, __LINE__);     \
            return RET_INVALID_ARG;                                     \
        }                                                               \
    } while (0)
 
/* 할당 결과 검사: 인자 오류(INVALID_ARG)가 아니라 NO_MEM 으로 구분한다 */
#define CHK_ALLOC(_p)                                                   \
    do {                                                                \
        if ((_p) == NULL) {                                             \
            LOG_ERR("alloc %s failed (%s/%d)", #_p, __func__, __LINE__);\
            return RET_NO_MEM;                                          \
        }                                                               \
    } while (0)
 
#define CHK_RANGE(_v, _min, _max)                                       \
    do {                                                                \
        if ((_v) < (_min) || (_v) > (_max)) {                           \
            LOG_ERR("%s(%d) out of range [%d,%d] (%s/%d)",              \
                    #_v, (int)(_v), (int)(_min), (int)(_max),           \
                    __func__, __LINE__);                                \
            return RET_INVALID_ARG;                                     \
        }                                                               \
    } while (0)
 
#define CHK_STR(_s)                                                     \
    do {                                                                \
        if ((_s) == NULL || (_s)[0] == '\0') {                          \
            LOG_ERR("%s is empty (%s/%d)", #_s, __func__, __LINE__);    \
            return RET_INVALID_ARG;                                     \
        }                                                               \
    } while (0)
 
#define CHK_RET(_expr)                                                  \
    do {                                                                \
        int _r = (_expr);                                               \
        if (_r != RET_OK) {                                             \
            LOG_ERR("%s failed: %s(%d) (%s/%d)",                        \
                    #_expr, ret_str(_r), _r, __func__, __LINE__);       \
            return _r;                                                  \
        }                                                               \
    } while (0)
 
/* ── goto 로 빠지는 가드 (자원을 이미 잡았을 때) ── */
 
#define CHK_PTR_GOTO(_p, _rv, _label)                                   \
    do {                                                                \
        if ((_p) == NULL) {                                             \
            LOG_ERR("%s is NULL (%s/%d)", #_p, __func__, __LINE__);     \
            (_rv) = RET_INVALID_ARG;                                    \
            goto _label;                                                \
        }                                                               \
    } while (0)
 
#define CHK_ALLOC_GOTO(_p, _rv, _label)                                 \
    do {                                                                \
        if ((_p) == NULL) {                                             \
            LOG_ERR("alloc %s failed (%s/%d)", #_p, __func__, __LINE__);\
            (_rv) = RET_NO_MEM;                                         \
            goto _label;                                                \
        }                                                               \
    } while (0)
 
#define CHK_RANGE_GOTO(_v, _min, _max, _rv, _label)                     \
    do {                                                                \
        if ((_v) < (_min) || (_v) > (_max)) {                           \
            LOG_ERR("%s(%d) out of range [%d,%d] (%s/%d)",              \
                    #_v, (int)(_v), (int)(_min), (int)(_max),           \
                    __func__, __LINE__);                                \
            (_rv) = RET_INVALID_ARG;                                    \
            goto _label;                                                \
        }                                                               \
    } while (0)
 
#define CHK_STR_GOTO(_s, _rv, _label)                                   \
    do {                                                                \
        if ((_s) == NULL || (_s)[0] == '\0') {                          \
            LOG_ERR("%s is empty (%s/%d)", #_s, __func__, __LINE__);    \
            (_rv) = RET_INVALID_ARG;                                    \
            goto _label;                                                \
        }                                                               \
    } while (0)
 
#define CHK_RET_GOTO(_expr, _rv, _label)                                \
    do {                                                                \
        (_rv) = (_expr);                                                \
        if ((_rv) != RET_OK) {                                          \
            LOG_ERR("%s failed: %s(%d) (%s/%d)",                        \
                    #_expr, ret_str(_rv), (_rv), __func__, __LINE__);   \
            goto _label;                                                \
        }                                                               \
    } while (0)
 
#define CHK_COND_GOTO(_cond, _code, _rv, _label)                        \
    do {                                                                \
        if (!(_cond)) {                                                 \
            LOG_ERR("cond(%s) failed (%s/%d)", #_cond, __func__, __LINE__); \
            (_rv) = (_code);                                            \
            goto _label;                                                \
        }                                                               \
    } while (0)
 
#endif /* GUARD_H */

2-2. 매크로 해독 — 왜 이 모양인가

부분왜 필요한가
do { ... } while (0)if (x) CHK_PTR(p); else ... 에서 문법이 깨지지 않게. → do-while(0) 노트
#_p (stringify)매크로 인자를 문자열로 만든다. CHK_PTR(name) → 로그에 "name is NULL"
(_p) 괄호CHK_RANGE(a+b, 0, 10) 같은 식이 들어와도 우선순위가 안 깨지게
__func__, __LINE__로그만 보고 어느 함수 몇 번째 줄인지 바로 찾게
int _r 지역변수_expr 을 두 번 평가하지 않기 위해. 함수 호출이면 두 번 불린다!
_goto 판의 _rv결과를 담을 변수를 인자로 명시한다 (아래 참고)

표 항목 디테일

do { ... } while (0)

if (ptr == NULL) CHK_PTR(ptr); else foo();

매크로가 단순 { ... } 였다면 세미콜론 때문에 if-else 가 깨진다(if(...){...};else... 는 문법 에러). do{...}while(0) 으로 감싸면 CHK_PTR(ptr); 전체가 세미콜론 하나로 끝나는 단일 문(statement) 처럼 동작해서 if/else, 중첩 매크로 어디에 넣어도 안전하다. while(0) 이라 실제로 한 번만 돈다.

#_p (stringize) 전처리기 연산자. CHK_PTR(name) 이라고 호출하면 매크로 인자로 넘어온 토큰 텍스트 name 을 그대로 큰따옴표로 감싸 "name" 이라는 문자열 리터럴로 바꾼다. 값이 아니라 소스코드에 적힌 글자 그대로라는 게 핵심 (2-2-2 참고).

(_p) 괄호 매크로는 텍스트 치환이라 괄호가 없으면 인자로 들어온 표현식이 다른 연산자와 잘못 결합된다.

#define CHK_RANGE(_v, ...)  ... if ((_v) < ...)   // ✓
CHK_RANGE(a + b, 0, 10)
→ if ((a + b) < ...)   // ✓ 괄호 있으면 안전
→ if (a + b < ...)     // ✗ 괄호 없으면 뒤 연산자와 우선순위가 깨질 수 있음

함수 인자는 호출 시점에 값으로 먼저 평가되니 이런 문제가 없지만, 매크로는 텍스트 그대로 붙여넣기라서 방어적으로 항상 괄호를 친다.

__func__, __LINE__ — 관례가 아니라 표준 메커니즘, 근데 둘이 다르게 동작함

정체언제 치환되나표준 근거
__LINE__전처리기 매크로전처리 단계에서 그 줄의 줄번호 정수 리터럴로 텍스트 치환C 표준 6.10.8 (Predefined macro names)
__func__암묵적 지역 변수 (매크로 아님!)컴파일러가 각 함수 진입부에 static const char __func__[] = "함수이름"; 을 자동 삽입한 것처럼 동작 → 런타임에 그 배열을 읽음C99 §6.4.2.2 (Predefined identifiers)

#include 없이 그냥 쓸 수 있는 이유가 이 둘 다 컴파일러/전처리기 내장이기 때문. __LINE__ 은 # 으로 문자열화할 수 없고(정수 리터럴이라 %d 로 찍음), __func__ 는 이미 문자열이라 %s 로 바로 찍는다 — LOG_ERR(..., __func__, __LINE__) 의 포맷이 %s/%d 로 짝지어진 이유.

int _r 지역변수 — 인자를 두 번 평가하지 않기 위해

#define CHK_RET(_e)  if ((_e) != RET_OK) { LOG_ERR("%d", (_e)); return (_e); }  // ✗

이렇게 짜면 _e 가 텍스트로 세 곳(if, LOG_ERR, return)에 복붙되어 send_packet(fd) 가 세 번 실행된다. 그래서

int _r = (_expr);   // 딱 한 번만 실행하고 결과를 변수에 저장
if (_r != RET_OK) { LOG_ERR(..., _r, ...); return _r; }

로 한 번 평가한 값을 재사용한다. 함수 호출뿐 아니라 i++ 같은 부작용 있는 표현식에서도 중요.

_goto 판의 _rv — 결과 변수를 인자로 받는다

#define CHK_PTR_GOTO(_p, _rv, _label)     do { if ((_p)==NULL) { ...; (_rv) = RET_INVALID_ARG; goto _label; } } while(0)
 
CHK_PTR_GOTO(buf, ret, CLEANUP);   /* 호출부에 'ret 이 바뀐다' 가 그대로 보인다 */

매크로가 호출 함수의 ret 이라는 이름을 몰래 캡처하지 않고, 결과를 담을 변수를 _rv 인자로 받는다. 이름 약속에 기대지 않으니 변수 이름이 ret 이 아니어도 되고, 바깥 스코프에 같은 이름의 다른 변수가 있어도 엉뚱한 변수를 덮어쓰지 않는다. CHK_COND_GOTO 는 값(_code)과 저장할 변수(_rv)가 둘 다 인자다. 인자는 (_rv) 로 괄호를 쳐서 쓴다.

할당 실패는 CHK_ALLOC* 로 — CHK_PTR* 와 에러코드를 구분한다

malloc/calloc 이 NULL 이면 원인은 인자가 아니라 메모리 부족이다. CHK_PTR* (RET_INVALID_ARG) 를 쓰면 로그에서 두 원인이 섞이므로, 할당 결과는 CHK_ALLOC* (RET_NO_MEM) 로 검사한다. fopen 처럼 실패 원인이 I/O 인 경우는 CHK_COND_GOTO(fp != NULL, RET_IO_ERROR, ...) 를 쓴다.

과거에는 ret 을 암묵적으로 썼다

예전 버전은 CHK_*_GOTO(_p, _label) 로 호출하고 매크로 안에서 ret = ... 를 직접 썼다. 짧긴 했지만 호출부에 ret 이 보이지 않았고 CHK_COND_GOTO 는 값만 인자로 받는 반쪽 명시였다. 지금은 모두 _rv 를 명시한다. 함수에는 여전히 int ret = RET_FAIL; 이 있어야 넘길 변수가 생긴다.

인자를 두 번 평가하는 매크로를 만들지 말 것

#define CHK_RET(_e)  if ((_e) != RET_OK) { LOG_ERR("%d", (_e)); return (_e); }  /* ✗ */

CHK_RET(send_packet(fd)) 라고 쓰면 패킷이 세 번 전송된다. 반드시 int _r = (_expr); 로 한 번만 평가해서 지역변수에 담는다.

2-2-1. #_v 는 “이름”, (_v) 는 “값” — 전처리 vs 런타임

CHK_RANGE 하나에 #_v 와 (int)(_v) 가 같은 인자를 서로 다른 방식으로 두 번 씁니다. 헷갈리기 쉬운 지점이라 따로 정리.

CHK_RANGE(v, 0, 100);   /* v 는 지금 999 */
/* 매크로 정의 */
LOG_ERR("%s(%d) out of range [%d,%d] (%s/%d)",
        #_v, (int)(_v), (int)(_min), (int)(_max), __func__, __LINE__);
자리치환 결과언제 결정되나
#_v (%s)"v"전처리 단계. 호출부에 적힌 토큰(글자) v 를 그대로 문자열화. v 가 몇 값을 담고 있는지는 전혀 모름
(int)(_v) (%d)999런타임. _v 자리에 v 라는 코드가 그대로 삽입되어 실행되고, 그 순간의 변수값이 평가됨

출력: v(999) out of range [0,100] (take_ptr/14) → %s 가 “v”를 먹고, 바로 다음 %d 가 “999”를 먹은 것. #_v 혼자서 이름+값을 같이 주는 게 아니라, 포맷 문자열에 %s(%d) 두 지시자를 나란히 놓고 인자도 두 개(#_v, (int)(_v)) 나란히 줘서 “이름(값)” 모양을 만든 것.

매크로 인자로 리터럴을 넘기면 결과가 달라진다

CHK_RANGE(v, 0, 100) 처럼 변수를 넘기면 #_v → "v". 반대로 CHK_RANGE(999, 0, 100) 처럼 리터럴을 직접 넘기면 #_v → "999" 가 되어 999(999) 로 찍힌다. # 연산자는 값이 아니라 호출부 소스코드에 적힌 텍스트 그대로를 문자열화하기 때문.

2-3. 어디까지 검사할 것인가

flowchart TD
    Q{이 함수를 누가 부르나?}
    Q -->|외부 입력<br/>네트워크·파일·사용자| A["전부 검사<br/>NULL, 범위, 길이, 포맷"]
    Q -->|같은 모듈 내부<br/>static 함수| B["최소한만<br/>NULL 정도"]
    Q -->|다른 모듈의 공개 API| C["NULL + 범위<br/>(계약 위반 잡기)"]

경계에서만 빡세게 검사한다

모든 함수에서 모든 인자를 검사하면 코드의 절반이 검사문이 된다. 외부에서 들어온 값은 한 번 철저히 검사하고, 그 뒤로는 믿는다. 단, static 함수라도 포인터 NULL 검사는 남겨두는 게 디버깅에 유리하다.


3. 로그 — 가드 매크로가 쓸 최소한의 로그

프레임워크가 없으면 로그도 직접 만들어야 한다. 최소 구성은 이것이면 충분하다.

/* ══════════════ log.h ══════════════ */
#ifndef LOG_H
#define LOG_H
 
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <time.h>    /* localtime_r: -std=c11 이면 -D_POSIX_C_SOURCE=200809L 필요 */
 
typedef enum {
    LOG_LV_ERR = 0,
    LOG_LV_WRN = 1,
    LOG_LV_INF = 2,
    LOG_LV_DBG = 3,
} log_level_t;
 
extern log_level_t g_log_level;     /* 런타임에 바꿀 수 있게 전역 */
 
#define LOG_PRINT(_lv, _tag, _fmt, ...)                                  \
    do {                                                                 \
        if ((_lv) <= g_log_level) {                                      \
            time_t _t = time(NULL);                                      \
            struct tm _tm;                                               \
            char _ts[20];                                                \
            localtime_r(&_t, &_tm);                                      \
            strftime(_ts, sizeof(_ts), "%Y-%m-%d %H:%M:%S", &_tm);       \
            fprintf(stderr, "[%s][%s] " _fmt "\n", _ts, _tag,            \
                    ##__VA_ARGS__);                                      \
        }                                                                \
    } while (0)
 
#define LOG_ERR(_fmt, ...)  LOG_PRINT(LOG_LV_ERR, "ERR", _fmt, ##__VA_ARGS__)
#define LOG_WRN(_fmt, ...)  LOG_PRINT(LOG_LV_WRN, "WRN", _fmt, ##__VA_ARGS__)
#define LOG_INF(_fmt, ...)  LOG_PRINT(LOG_LV_INF, "INF", _fmt, ##__VA_ARGS__)
#define LOG_DBG(_fmt, ...)  LOG_PRINT(LOG_LV_DBG, "DBG", _fmt, ##__VA_ARGS__)
 
/* errno 를 먼저 저장한다 — LOG_PRINT 안의 time/localtime_r 이 errno 를 바꿀 수 있다 */
#define LOG_ERRNO(_fmt, ...)                                             \
    do {                                                                 \
        int _e = errno;                                                  \
        LOG_ERR(_fmt ": %s(%d)", ##__VA_ARGS__, strerror(_e), _e);       \
    } while (0)
 
#endif /* LOG_H */
부분설명
##__VA_ARGS__GCC 확장. 가변인자가 비었을 때 앞의 쉼표를 지워준다. 없으면 LOG_ERR("hello") 가 컴파일 에러
(_lv) <= g_log_level레벨 검사를 매크로 안에서 하면, 꺼진 로그는 인자 조립 비용조차 안 든다
localtime_rlocaltime 은 스레드 안전하지 않다. 멀티스레드면 반드시 _r 판
fprintf(stderr, ...)버퍼링 없이 바로 나간다. 죽기 직전 로그가 안 날아간다

로그 포맷을 프로젝트 시작할 때 정하라

나중에 바꾸면 이미 쌓인 로그를 파싱하던 스크립트가 전부 깨진다. 최소한 [시각][레벨] 메시지 (함수/줄) 형태는 지키는 게 좋다.


4. 메모리 누수 방어

4-1. 해제 매크로 — 두 번 free 를 막는다

/* ══════════════ safe.h ══════════════ */
#ifndef SAFE_H
#define SAFE_H
 
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "common.h"
 
#define SAFE_FREE(_p)                                                   \
    do {                                                                \
        if ((_p) != NULL) {                                             \
            free(_p);                                                   \
            (_p) = NULL;        /* ★ 이 한 줄이 double free 를 막는다 */ \
        }                                                               \
    } while (0)
 
#define SAFE_FCLOSE(_fp)                                                \
    do { if ((_fp) != NULL) { fclose(_fp); (_fp) = NULL; } } while (0)
 
#define SAFE_CLOSE(_fd)                                                 \
    do { if ((_fd) >= 0) { close(_fd); (_fd) = -1; } } while (0)
 
/* 쓰기용 파일: 쓰기 오류는 fclose 에서 드러날 수 있다.
 * 이미 실패한 _rv 는 덮어쓰지 않는다 (첫 실패 원인을 보존). */
#define SAFE_FCLOSE_RET(_fp, _rv)                                       \
    do {                                                                \
        if ((_fp) != NULL) {                                            \
            if (fclose(_fp) != 0 && (_rv) == RET_OK) {                  \
                (_rv) = RET_IO_ERROR;                                   \
            }                                                           \
            (_fp) = NULL;                                               \
        }                                                               \
    } while (0)
 
#endif /* SAFE_H  (SAFE_CPY / IS_ARRAY 는 아래 4-2) */

읽기 파일은 SAFE_FCLOSE, 쓰기 파일은 SAFE_FCLOSE_RET

버퍼에 남은 데이터는 fclose 시점에 디스크로 나가므로, 디스크 가득 참 같은 오류가 거기서 드러난다. 반환값을 버리는 SAFE_FCLOSE 로 쓰기 파일을 닫으면 저장 실패를 성공으로 보고할 수 있다.

= NULL 을 빼먹으면 언젠가 죽는다

free(buf);          /* ✗ buf 는 여전히 옛 주소를 가리킨다 (dangling) */
...
if (buf) free(buf); /* ✗ NULL 이 아니므로 통과 → double free → 즉사 */

free(NULL) 은 합법이고 아무 일도 안 한다. 그러니 NULL 로 만들어두면 안전하다. 매크로로 감싸면 이걸 빼먹는 게 불가능해진다. 그게 매크로를 쓰는 이유다.

4-2. 문자열 복사 — strcpy 를 쓰지 않는다

/* ── 항상 널 종단을 보장하는 복사 (safe.h) ───────────────── */
/* 배열이면 1, 포인터면 0 (GCC/Clang 전용) */
#define IS_ARRAY(_a)                                                    \
    (!__builtin_types_compatible_p(__typeof__(_a), __typeof__(&(_a)[0])))
 
/* dst 가 포인터면 컴파일 에러 (sizeof(char[-1])) */
#define SAFE_CPY(_dst, _src)                                            \
    do {                                                                \
        (void)sizeof(char[IS_ARRAY(_dst) ? 1 : -1]);                    \
        snprintf((_dst), sizeof(_dst), "%s", (_src) ? (_src) : "");     \
    } while (0)
함수문제
strcpy(dst, src)길이 검사 없음. 버퍼 오버플로의 1순위 원인
strncpy(dst, src, n)src 가 n 이상이면 널 종단을 안 붙인다. 이후 읽기가 폭주
snprintf(dst, sizeof(dst), "%s", src)✓ 잘리더라도 항상 널 종단. 반환값으로 잘림도 감지 가능

SAFE_CPY 의 sizeof(_dst) 함정 — 지금은 컴파일 에러로 막는다

이 매크로는 _dst 가 배열일 때만 맞다.

char buf[64];        SAFE_CPY(buf, src);   /* ✓ sizeof = 64 */
char *p = malloc(64); SAFE_CPY(p, src);    /* ✗ 컴파일 에러 (IS_ARRAY 가 0) */

예전 버전은 포인터에 쓰면 sizeof 가 8 이라 8바이트만 복사되고 조용히 잘렸다. 크래시도 안 났다. 포인터에는 크기를 명시하는 snprintf(p, 64, "%s", src) 를 쓰고, 잘림 여부가 필요하면 str_copy() (종합본 §3-3) 를 쓴다. 또, 리터럴이 dst 보다 긴 게 확실하면 -Werror=format-truncation 이 여기서 터지는데 정상 동작이다.

4-3. 할당 실패도 반드시 검사

buf = malloc(len);
if (buf == NULL) {
    LOG_ERRNO("malloc(%zu) failed", len);   /* errno 를 먼저 저장하고 출력 */
    ret = RET_NO_MEM;
    goto CLEANUP;
}
memset(buf, 0x00, len);        /* ★ malloc 은 초기화를 안 해준다 */

위 4줄은 CHK_ALLOC_GOTO(buf, ret, CLEANUP); 한 줄과 같다 (로그는 변수 이름과 위치만 남긴다).

calloc(n, size) 는 malloc + memset 이다

0으로 채울 거라면 calloc 한 줄이 더 짧고, 곱셈 오버플로 검사도 해준다.

buf = calloc(cnt, sizeof(item_t));   /* cnt * sizeof 가 넘치면 NULL 반환 */

5. 완성된 복붙 골격

5-1. 자원을 안 잡는 함수 (제일 흔함)

/* ══════════════ 자원 없음: 가드 + 바로 return ══════════════ */
int {{함수명}}(const {{입력타입}} *in, {{출력타입}} *out)
{
    CHK_PTR(in);
    CHK_PTR(out);
    CHK_RANGE(in->{{필드}}, {{최소}}, {{최대}});
 
    memset(out, 0x00, sizeof(*out));      /* ★ 출력은 항상 초기화부터 */
 
    /* ───────── 여기서만 생각한다 ───────── */
    {{비즈니스 로직}}
    /* ──────────────────────────────────── */
 
    return RET_OK;
}

5-2. 자원을 잡는 함수 (누수 방어판)

/* ══════════════ 자원 있음: goto CLEANUP ══════════════ */
int {{함수명}}(const {{입력타입}} *in, {{출력타입}} *out)
{
    int    ret  = RET_FAIL;              /* ① 실패가 기본값 */
    char  *buf  = NULL;                  /* ② 포인터는 전부 NULL */
    FILE  *fp   = NULL;
    int    fd   = -1;                    /*    fd 는 -1 */
 
    CHK_PTR(in);                         /* ③ 자원 잡기 전 검사는 즉시 return */
    CHK_PTR(out);
 
    memset(out, 0x00, sizeof(*out));
 
    buf = calloc(1, {{크기}});           /* ④ 여기서부터는 goto CLEANUP */
    CHK_ALLOC_GOTO(buf, ret, CLEANUP);
 
    fp = fopen(in->{{경로}}, "r");
    CHK_COND_GOTO(fp != NULL, RET_IO_ERROR, ret, CLEANUP);
 
    /* ───────── 비즈니스 로직 ───────── */
    CHK_RET_GOTO({{하위함수}}(in, buf), ret, CLEANUP);
    CHK_COND_GOTO({{조건}}, RET_BAD_FORMAT, ret, CLEANUP);
    /* ──────────────────────────────── */
 
    ret = RET_OK;                        /* ⑤ 끝까지 왔을 때만 성공 */
 
CLEANUP:                                 /* ⑥ 잡은 역순으로 해제 */
    SAFE_FCLOSE(fp);
    SAFE_FREE(buf);
    SAFE_CLOSE(fd);
 
    if (ret != RET_OK) {
        memset(out, 0x00, sizeof(*out)); /* ★ 실패 시 출력을 쓰레기로 남기지 않는다 */
    }
    return ret;
}

실패했으면 out 을 비워라

호출부가 리턴값을 검사하지 않고 out 을 쓰는 경우가 실제로 자주 있다. 실패 경로에서 out 을 초기화해두면, 그런 실수가 크래시 대신 빈 값으로 끝난다.

5-3. 자원을 만들어서 돌려주는 함수

가장 실수가 잦은 유형이다. “성공하면 호출부가 free, 실패하면 내가 free” 규칙이 핵심.

/* ══════════════ 생성 함수: 소유권을 넘긴다 ══════════════ */
/*  성공: *out_obj 를 채워서 반환. 호출부가 {{타입}}_destroy() 해야 함
 *  실패: *out_obj 는 NULL. 내부에서 이미 전부 정리됨               */
int {{타입}}_create(const {{설정타입}} *cfg, {{타입}} **out_obj)
{
    int      ret = RET_FAIL;
    {{타입}} *obj = NULL;
 
    CHK_PTR(cfg);
    CHK_PTR(out_obj);
 
    *out_obj = NULL;                     /* ★ 출력 포인터를 먼저 NULL 로 */
 
    obj = calloc(1, sizeof({{타입}}));
    CHK_ALLOC_GOTO(obj, ret, CLEANUP);
 
    obj->{{버퍼}} = calloc(cfg->{{개수}}, sizeof({{항목타입}}));
    CHK_ALLOC_GOTO(obj->{{버퍼}}, ret, CLEANUP);
 
    CHK_RET_GOTO({{추가초기화}}(obj, cfg), ret, CLEANUP);
 
    *out_obj = obj;                      /* ★ 성공할 때만 넘긴다 */
    obj      = NULL;                     /* ★★ 소유권을 넘겼으니 내 손에서 뗀다 */
    ret      = RET_OK;
 
CLEANUP:
    if (obj != NULL) {                   /* ★ 여기 오면 obj 는 "실패한 놈" */
        {{타입}}_destroy(obj);           /*    성공 경로에서는 NULL 이라 안 탄다 */
    }
    return ret;
}
 
void {{타입}}_destroy({{타입}} *obj)
{
    if (obj == NULL) return;             /* ★ NULL 을 받아도 안전하게 */
 
    SAFE_FREE(obj->{{버퍼}});            /* 안쪽부터 */
    SAFE_FREE(obj);                      /* 바깥은 마지막 */
}

obj = NULL; 한 줄이 핵심이다

성공 경로에서 이걸 빼면, CLEANUP 에서 방금 호출부에 넘긴 객체를 destroy 해버린다. 호출부는 이미 해제된 메모리를 받게 된다 (use-after-free).

관용구로 외운다: “넘겼으면 놓는다 (*out = obj; obj = NULL;)” 사내 코드의 ATIPC 소유권 규칙과 정확히 같은 개념이다 → 실무 6. IPC·HTTP2 송수신 골격

destroy 는 항상 NULL 을 받아도 되게 만든다

free(NULL) 이 합법인 것처럼, 내가 만든 destroy 도 그래야 한다. 그래야 호출부가 if (obj) destroy(obj); 를 안 써도 되고, CLEANUP 코드가 짧아진다.


6. 단위 시험 — 최소 3개

함수 하나당 정상 / 경계 / NULL 세 개만 써도 버그의 대부분이 잡힌다.

/* ══════════════ test_{{모듈}}.c ══════════════ */
#include <assert.h>
#include "{{모듈}}.h"
 
static void test_normal(void)
{
    {{입력타입}} in  = { .{{필드}} = {{정상값}} };
    {{출력타입}} out;
 
    assert({{함수명}}(&in, &out) == RET_OK);
    assert(out.{{결과필드}} == {{기대값}});
    printf("  [OK] normal\n");
}
 
static void test_boundary(void)
{
    {{입력타입}} in;
    {{출력타입}} out;
 
    in.{{필드}} = {{최소}};        assert({{함수명}}(&in, &out) == RET_OK);
    in.{{필드}} = {{최대}};        assert({{함수명}}(&in, &out) == RET_OK);
    in.{{필드}} = {{최소}} - 1;    assert({{함수명}}(&in, &out) == RET_INVALID_ARG);
    in.{{필드}} = {{최대}} + 1;    assert({{함수명}}(&in, &out) == RET_INVALID_ARG);
    printf("  [OK] boundary\n");
}
 
static void test_null(void)
{
    {{입력타입}} in = { 0 };
    {{출력타입}} out;
 
    assert({{함수명}}(NULL, &out)  == RET_INVALID_ARG);
    assert({{함수명}}(&in,  NULL)  == RET_INVALID_ARG);
    assert({{함수명}}(NULL, NULL)  == RET_INVALID_ARG);
    printf("  [OK] null\n");
}
 
int main(void)
{
    printf("test_{{모듈}}:\n");
    test_normal();
    test_boundary();
    test_null();
    printf("all passed\n");
    return 0;
}
# 누수까지 한 번에 확인
gcc -g -O0 -Wall -Wextra -fsanitize=address,undefined \
    test_{{모듈}}.c {{모듈}}.c -o test_{{모듈}}
./test_{{모듈}}

-fsanitize=address 는 공짜 QA 다

누수, 버퍼 오버플로, use-after-free, double free 를 실행 즉시 잡아서 위치까지 알려준다. 개발 중에는 항상 켜두고, 릴리즈 빌드에서만 뺀다. undefined 도 같이 켜면 부호 오버플로·정렬 위반까지 잡힌다.


7. 컴파일 옵션 — 경고가 최고의 정적분석기다

CFLAGS  = -std=c11 -Wall -Wextra -Werror
CFLAGS += -Wshadow            # 바깥 변수를 가리는 지역변수
CFLAGS += -Wpointer-arith     # void* 산술
CFLAGS += -Wcast-align        # 정렬이 안 맞는 캐스팅 (엔디안 코드에서 중요)
CFLAGS += -Wstrict-prototypes # f() 대신 f(void)
CFLAGS += -Wformat=2          # printf 포맷과 인자 타입 불일치
CFLAGS += -D_GNU_SOURCE
 
# 개발용
DBGFLAGS = -g -O0 -fsanitize=address,undefined
# 릴리즈용
RELFLAGS = -O2 -DNDEBUG
옵션잡아주는 것
-Wall -Wextra초기화 안 한 변수, 안 쓰는 변수, 비교 부호 불일치
-Werror경고를 에러로. “나중에 고치지” 를 원천 차단
-Wformat=2printf("%d", ptr) 같은 런타임에야 터질 버그를 컴파일 타임에
-Wcast-alignchar* 를 uint32_t* 로 캐스팅 → ARM 에서 죽는 코드

-Werror 는 처음부터 켜라

기존 코드에 경고가 500개 쌓인 뒤에 켜려면 아무도 못 켠다. 새 프로젝트는 무조건 처음부터.


8. 체크리스트

함수 하나를 다 썼을 때

  • ret 초기값이 실패인가
  • 모든 포인터를 NULL 로 선언했는가 (fd 는 -1)
  • 인자 검사가 함수 첫 부분에 있는가
  • 자원을 잡은 뒤의 에러는 전부 goto CLEANUP 인가
  • CLEANUP 의 해제 순서가 잡은 역순인가
  • free 뒤에 = NULL 이 있는가 (SAFE_FREE 를 썼는가)
  • malloc / fopen / open 의 반환값을 검사했는가
  • 실패 경로에서 out 을 초기화했는가
  • strcpy / strcat / sprintf 를 하나도 안 썼는가
  • 생성 함수라면 *out = obj; obj = NULL; 을 했는가

모듈 하나를 다 썼을 때

  • -Wall -Wextra -Werror 로 경고 0개인가
  • -fsanitize=address 로 시험이 통과하는가
  • 함수마다 정상/경계/NULL 시험 3개가 있는가
  • 에러코드를 중간에서 덮어쓰지 않고 그대로 올리는가
  • 로그에 __func__ / __LINE__ 이 들어가는가

관련 노트